feat(oidc): JWT userinfo support for eSignet [DHIS2-20043]#23839
feat(oidc): JWT userinfo support for eSignet [DHIS2-20043]#23839netroms wants to merge 27 commits into
Conversation
…0043] Per-provider config switch (user_info_response_type=json|jwt, default json) lets eSignet's signed-JWT userinfo flow be selected via dhis.conf without altering behaviour for any existing OIDC provider. Generic provider builder is the integration point. Captures scope, component contracts, validation rules, tests, and the decision to keep principalNameAttribute=sub in both modes. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…[DHIS2-20043] Eleven-task TDD plan covering enum, algorithm allow-list, registration fields, generic builder wiring, parser validation, JWKSource cache, SignedJwtUserInfoLoader, DhisOidcUserService dispatch, OAuth2Test re-enable, and PublicKeysController thumbprint cherry-pick. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…HIS2-20043] AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onfig keys [DHIS2-20043] AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ation [DHIS2-20043] AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nder private_key_jwt [DHIS2-20043] - Register USER_INFO_RESPONSE_TYPE and USER_INFO_JWS_ALGORITHM in the KEY_REQUIRED_MAP allow-list so the parser accepts them as valid keys - Add validateUserInfoResponseType() to reject unknown response type values and unsupported JWS algorithms (via SupportedJwsAlgorithms) - Add isPrivateKeyJwt() helper and skip the client_secret required-check when private_key_jwt + all keystore properties are present - Wire userInfoResponseType and userInfoJwsAlgorithm in GenericOidcProviderBuilder.build() so the registration reflects config - Guard clientSecret null check in builder (was NPE for private_key_jwt) AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… [DHIS2-20043] - Replace lenient null-check with logic that throws unless private_key_jwt - Add isPrivateKeyJwt() helper for reusable auth-method detection - Guard getJWK() against null keystore path when no keystore configured - Skip clientSecret on ClientRegistration when null/empty - Add four direct GenericOidcProviderBuilder unit tests covering JWT/JSON userinfo defaults, missing-secret enforcement, and private_key_jwt bypass AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…2-20043] Mockito's bundled ByteBuddy (1.14.x) only formally supports Java up to 20; running on Java 21 needs the experimental flag to inline-mock final classes. Adding it to surefireArgLine + threading argLine through the surefire pluginManagement keeps Java 17 builds unaffected and unblocks local Java 21 dev-machine builds. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…[DHIS2-20043] Loads OIDC userinfo from providers (e.g. MOSIP eSignet) that respond with a signed JWT instead of plain JSON: GET userinfo with Accept: application/jwt → verify signature against the IdP's JWKS using the registered JWS algorithm → extract the mapping claim → resolve the local DHIS2 user. principalNameAttribute remains sub so audit logs match the JSON path. Reuses the existing restTemplate bean from dhis-support-system. Distinct OAuth2Error codes for fetch failure, JWS-verification failure, missing mapping claim, and unknown user. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-20043] AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Additive: emits x509 SHA-256 thumbprint on the JWK only when the underlying registration's JWK carries one (returns null otherwise, which Nimbus's RSAKey builder accepts). Cherry-picked from PR #22027. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…KS [DHIS2-20043] The previous SignedJwtUserInfoLoader was passing DhisOidcClientRegistration.jwkSetUrl (which is DHIS2's *own* JWKS URL, used to advertise client-auth keys for private_key_jwt) to JwkSourceCache when verifying the userinfo JWT. That URL would never resolve the IdP's signing keys, so signature verification would always fail in production. Switched to ClientRegistration.providerDetails.jwkSetUri (populated from the jwk_uri config key — already required and validated). Updated the test to mock that endpoint accordingly. No extra parser validation is needed because jwk_uri is already in the required-keys map. Also addressed code-review nits in the same change: - replaced Collections.singletonList with List.of (per AGENTS.md) - added disabledUserRaisesUserDisabled test exercising the user_disabled error code path AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…20043-esignet-oidc-jwt-userinfo
…r [DHIS2-20043] Stands up the JDK's built-in HttpServer as an in-process eSignet-style IdP and exercises the full stack (real RestTemplate, real JwkSourceCache, real Nimbus JWT verification) end to end. Zero new dependencies. Covers happy path plus three negatives: bad-key JWT -> jwt_processing_error, IdP 500 -> invalid_user_info_response, and missing mapping claim -> missing_mapping_claim. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-up docs for the signed-JWT userinfo work: * OAUTH2_JWT_USERINFO_HARDENING.md — four defense-in-depth items surfaced by the security review (sub binding per OIDC 5.3.2, iss/aud verification, private_key_jwt parser strictness, error-description hygiene). Not exploitable today; track for hardening before eSignet prod use. * OIDC_E2E_MOCK_IDP_PLAN.md — comprehensive plan for a generic OIDC RP-side e2e test harness: small custom Java mock IdP service in the e2e module (signed-JWT userinfo is rare enough that no off-the-shelf option supports it), per-realm scenario API, test class per OIDC feature, compose overlay and CI wiring. Phase 0 is the in-process HTTP integration test landed in this branch; phases 1-5 are scoped for follow-up work. AI Assisted Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Not sure what's the point of this docs directory. Presumably it's meant for a bot agent? Let's leave this outside of this PR since we shouldn't introduce a new WoW without a discussion with the wider team.
There was a problem hiding this comment.
Thanks, this was not meant to be in the PR.
|
Is this going to be backported to v43 @netroms ? We need the feature for this particular version. |
| HttpEntity<String> entity = new HttpEntity<>("", headers); | ||
| try { | ||
| ResponseEntity<String> response = | ||
| restTemplate.exchange(userInfoUri, HttpMethod.GET, entity, String.class); |
There was a problem hiding this comment.
Are there any timeouts set on this instance of RestTemplate?
| clientRegistration.getRegistrationId()); | ||
| /** | ||
| * JSON-userinfo path: delegates to Spring's {@link OidcUserService#loadUser(OidcUserRequest)}, | ||
| * then resolves the mapping claim to a local DHIS2 user. Package-private to allow direct stubbing |
There was a problem hiding this comment.
Redundant comment IMO:
Package-private to allow direct stubbing in {@link DhisOidcUserServiceDispatchTest}.
If the access modifier is narrowed by mistake, then the test would break at compilation so there's really no need to say this.
| */ | ||
| @ExtendWith(MockitoExtension.class) | ||
| @MockitoSettings(strictness = Strictness.LENIENT) | ||
| class SignedJwtUserInfoLoaderHttpIntegrationTest { |
There was a problem hiding this comment.
I got an earful last time when I wrote an integration test in the dhis-service-core module. This should be moved to the dhis-test-integration or dhis-test-web-api modules.
| httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); | ||
| httpServer.createContext("/jwks.json", ex -> respond(ex, 200, "application/json", publicJwks)); | ||
| httpServer.createContext("/userinfo", this::serveUserInfo); | ||
| httpServer.setExecutor(null); | ||
| httpServer.start(); | ||
| port = httpServer.getAddress().getPort(); |
There was a problem hiding this comment.
I believe this together with the stub plumbing can be reduced if MockServer is used instead. We already use MockServer in other places within the test code base. Besides, the HttpServer API is relatively low-level.
Add RestTemplate timeouts (5s connect, 10s read), remove redundant Javadoc comment, move HTTP integration test to dhis-test-web-api with MockServer, and drop docs/superpowers/ from the PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…20043] Add missing assertion in parseAcceptsMissingClientSecretWithPrivateKeyJwt and extract Map.of() from assertThrows lambda in buildThrowsWhenSecretMissingAndNotPrivateKeyJwt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n [DHIS2-20043] Guard against null JWK in PublicKeysController.getKeys() which would NPE for non-private_key_jwt providers. Narrow catch(Exception) to catch(BadJOSEException | JOSEException | ParseException) so programming errors are not masked. Add null guard for userInfoJwsAlgorithm at the top of SignedJwtUserInfoLoader.load(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… exception handling [DHIS2-20043] Make resolveAlgorithm null-safe to prevent NPE when getJwk() is null for non-private_key_jwt providers. Narrow catch clause in SignedJwtUserInfoLoader.verify() from Exception to specific JOSE types. Add fail-fast check for null userInfoJwsAlgorithm in JWT mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s [DHIS2-20043] Extract resolveExternalAuthUser as a shared package-private static method on SignedJwtUserInfoLoader. Both JSON and JWT paths now use the same user lookup, externalAuth check, and disabled/expired validation with consistent error codes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… integration test [DHIS2-20043] Replace @Autowired field injection with constructor injection using private final fields in DhisOidcUserService. Move HTTP integration test from dhis-test-web-api to dhis-test-integration where it belongs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tegration [DHIS2-20043] Add mockserver-client-java, testcontainers, spring-security-oauth2-client, and nimbus-jose-jwt as explicit test dependencies to satisfy the dependency analysis plugin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|



Summary
application/jwt) instead of JSON. Selected via two new generic-provider config keys:user_info_response_type=json|jwt(defaultjson) anduser_info_jws_algorithm(defaultRS256, allow-list of RSA/ECDSA only).DhisOidcUserServiceregainsextends OidcUserServiceand dispatches to either Spring's standard JSON path or a newSignedJwtUserInfoLoaderbased on the registration'suserInfoResponseType.client_secretonly whenclient_authentication_method=private_key_jwtis satisfied;user_info_uriandjwk_uriremain required.Notable
SignedJwtUserInfoLoaderverifies the userinfo JWT against the IdP's JWKS via a per-registrationJwkSourceCache(Nimbus'sJWKSourceis built once and reused so its remote-key cache works as intended).OAuth2Errorcodes for the JWT path:invalid_user_info_response,jwt_processing_error,missing_mapping_claim,user_not_found,user_disabled.subin both modes so audit logs are unaffected.EsignetProviderclass. A completedhis.confexample lives in the design spec.Test plan
dhis-services/dhis-service-corefull module test: 789 pass, 0 fail (1 new vs master).mvn spotless:checkclean.SupportedJwsAlgorithmsTest(13 cases incl. parameterized algorithms + HMAC rejection),GenericOidcProviderBuilderConfigParserTest(+6 cases for JWT mode andprivate_key_jwtrelaxation),GenericOidcProviderBuilderTest(+4 cases for builder field wiring + missing-secret behaviour),SignedJwtUserInfoLoaderTest(6 cases: happy path, HTTP failure, bad signature, missing mapping claim, unknown user, disabled user),DhisOidcUserServiceDispatchTest(2 cases: JSON and JWT branch dispatch),SignedJwtUserInfoLoaderHttpIntegrationTest(4 cases: real HTTP via MockServer).AI Assisted